feat(rag): on-device with citations - #239
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
RAG improvements — analysis, research & backlogAnalysis of the local-first RAG pipeline on Scope of the app this targets: fully on-device (mid-range phones), ExecuTorch
1. What the pipeline does today
Verdict: the retrieval core is strong and in places ahead of common practice (RRF over weighted-sum, MMR, term-coverage boost, Polish diacritics + stem-prefix, neighbor expansion). The weak 10% was the tail: final-set selection, budget packing, and answer-time attribution. Most of that is now addressed (§5) — final-set selection (adaptive-k, per-file cap), budget packing (boundary truncation, leaner instructions), and answer-time attribution (truncation-honest → answer-echo → refusal suppression). Remaining tail work: short-code recall and context de-duplication (§2 A11/A12). 2. Bugs & tensions found in the code (verified with file:line)These are defects/mismatches, not enhancements — fix first.
3. Anti-recommendations (deliberately NOT doing — saves weeks)
4. Prioritized backlogPriority 1 — high-evidence, code-only, no new deps/models/migrations
Priority 2 — medium cost, high value
Priority 3 — roadmap
5. Implemented in this passCode-only, no new dependencies, no native changes, no re-index/data migration. All covered by unit tests.
Compact hot-path logs ( Tuning constants live in constants/retrieval.ts ( |
7cb456e to
c866f06
Compare
Add a hybrid retriever that fuses semantic vector search with exact keyword search and re-ranks the result on-device, replacing the plain vector-only path. No extra ML model is loaded — fusion and re-ranking are pure arithmetic. - keywordIndex: FTS5/BM25 index mirroring the vector-store chunks in the same op-sqlite DB; degrades to a no-op when FTS5 is absent in the build - rankFusion: Reciprocal Rank Fusion, cosine similarity, term coverage and Maximal Marginal Relevance primitives - hybridRetrieve: run vector + keyword search concurrently, hydrate keyword-only hits, fuse, gate out noise-floor filler, MMR-diversify, and float freshly-attached sources to the front (deterministic "Source N" / citation order) - Polish morphology: stem-prefix matching so "pliku" finds "plików", and manual ł/Ł folding the tokenizer's remove_diacritics misses - retrieval/keyword-index constants extracted to constants/ - VectorStoreContext: serialize init/teardown across effect runs, build the keyword index eagerly, lazy-load the embedding model, log unload failures Covered by unit tests for queryTerms, keywordIndex, rankFusion, hybridRetrieve and the context formatters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring in the scroll-down-button overlap fix (#251). Resolve the ChatScreen import conflict: keep the expo-router import and adopt scroll-down's useReanimatedKeyboardAnimation, dropping the now-unused KeyboardStickyView. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four files needed manual resolution, all of them semantic rather than textual conflicts: - promptUtils: both sides added a fifth parameter. Kept main's ordering (customSystemPrompt fifth, preferredSourceDocuments sixth) so the signature other branches were written against stays stable. - llmStore: main moved prepareMessagesForLLM below waitForModelLoad and behind waitForSettingsHydration (#240's cold-start fix). Kept that placement and moved this branch's citation restriction after the call instead of hoisting the prompt build back up, which would have reintroduced the unhydrated-prompt race. - ChatScreen: kept this branch's deferred navigation and buildSources callback. d956ad3 dropped setActiveChatId here on purpose — the chat id is passed to sendChatMessage explicitly and the route sets the active chat on mount — so main's re-added destructuring is dropped too. - useAttachment: kept this branch's clearAll, which is main's plus the in-flight embedding abort. Also realigned two llmStore tests to the buildSources callback signature.
CONTEXT_WINDOW_TOKENS_BY_FAMILY was an empty map, so every model fell through to the 2048 default and the prompt budget was a flat 4608 chars regardless of the model. That truncates retrieved context far earlier than any shipped model requires. The numbers are deliberately conservative rather than the upstream ones: the window is baked into the ExecuTorch export, not the base model, and nothing exposes it at runtime, so overshooting would overflow. Unknown and imported models keep the old default.
clearImportedSources swallowed a failing DELETE, and the caller stored the new model id anyway. The vectors were already gone at that point, so a partial failure left source rows pointing at nothing and, because the key had advanced, no later launch would retry. It now reports whether the wipe completed and the key is only written on success. Both delete paths are idempotent, so the retry is safe.
Citation overlap counted every term in the reply, so "the report does not mention revenue" shared "revenue" with the revenue passage and cited it — as support for the opposite of what the reply said. looksLikeNoAnswer only catches whole-reply refusals, not a negated clause inside an answer. Terms are now taken from the asserted clauses only, so "covers X but does not mention Y" still cites X. English cues only; the Polish side of refusal detection is handled separately on feat/rag-hybrid.
Neighbor windows were emitted in seed-similarity order, so one document could reach the model as chunks 9,10,11 followed by 2,3,4. The passage stitcher also assumes adjacency when it dedups overlapping text. Relevance still decides which chunks are selected and how documents are ranked against each other; only the order within a document changes. Note: feat/rag-hybrid replaces this file with utils/hybridRetrieval.ts, which carries a verbatim copy of expandSelectedWithNeighbors. That copy needs the same change or this fix disappears when the branch lands.
A document is embedded as a source the moment it is attached, but it is only tied to a chat on send, so abandoning one left the source behind forever. cleanupOrphanedSources existed but was unreachable: 822bf9b flipped the cleanupSources default to false, both remaining callers pass false explicitly, and the only caller relying on the default sits in a ChatBar handle nothing invokes. Cleanup now runs where a source is actually abandoned — removing an embedded attachment, and unmounting with one still in the composer. The send paths keep passing false, since enableSource is about to link the source to the chat. The handle's call is explicit now too, so no caller depends on the default.
The merge resolution reformatted this union with a locally stale prettier 3.8.3, undoing 08f66da and failing lint on CI, which installs the 3.9.4 from the lockfile. Restores the single-line form 3.9.4 produces.
visibleAnswer cut at the first <think> and resumed after the first </think>, so a second reasoning block landed in the text treated as the visible reply. Citation scoring runs on that text, which meant hidden reasoning could decide which sources got cited. An unterminated block still ends the visible reply, since the model is mid-thought and nothing after it has been said yet.
estimateTokens had no callers. The imperative clear() had none either — it was the only caller left relying on clearAll's cleanupSources default, which is why abandoned sources looked like they were being swept when nothing invoked the path. setInput stays; it is used for prompt suggestions.
Brings the base branch's review fixes onto the hybrid layer. Git detected utils/retrieval.ts -> utils/hybridRetrieval.ts as a rename, so most of it carried over on its own. Three things needed a decision: - embeddingModelMigration: this branch still had the pre-BLOCKER version with no dimension check. Took the base branch's version and moved dropKeywordIndex into clearImportedSources so the FTS index is dropped on every wipe path, not just the model-change one. - Reverted ff23ebc's neighbor ordering. It sorted each document's chunks into reading order, which this branch tests against on purpose: context is truncated from the tail, so a 10-K's table of contents at chunk 2 would survive while the answer at chunk 20 got cut. Windows are already emitted in ascending order internally; only their relative order is relevance-driven, and that is the right call. - Dropped __tests__/retrieval.test.ts, whose module no longer exists here. ff23ebc should be reverted on #239 for the same reason.
Reconciles the RAG sources/citations stack with the conversation forking feature (#227): MessageItem carries both the Sources button and the Copy/Fork action row, Messages renders branch markers next to RAG-annotated messages, and the completed assistant message in llmStore now keeps its persisted id together with the cited sources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
performance.now is Date.now under the RN jest preset (1 ms resolution), so on a fast CI machine the benchmark start and the first token could land in the same millisecond, collapsing timeToFirstToken to 0 and flaking the run. Drive the measurement with a virtual monotonic clock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves #221.
Adds retrieval-augmented generation over user documents, fully on-device. A user attaches PDFs/text, the app embeds and indexes them, and each reply is grounded in — and cited back to — the passages it actually used. No content leaves the device.
This is the baseline: vector retrieval end to end. The retrieval-quality layer (keyword/BM25 fusion, MMR, adaptive-k, multilingual refinements) is a stacked follow-up — see
feat/rag-hybrid— so this PR stays reviewable on its own.What's in it
documentId:chunkIndex). Extracted text is capped (MAX_SOURCE_TEXT_CHARS) before chunking so a pathological multi-MB document can't blow up the chunk array and exhaust memory; the result is flaggedtruncatedfor the UI.utils/retrieval.ts) — semantic search over the enabled sources, capped per document, ordered attachment-first, then expanded with neighboring chunks so a matched passage arrives with its surrounding context. The semantic-similarity floor is calibrated on-device (0.40; true paraphrase matches land ~0.28–0.54) and the single best candidate always survives above a0.25top-keep floor, so a paraphrase query never silently returns nothing.utils/contextUtils.ts) — groups chunks per document intoSource Nblocks and stitches adjacent passages, de-duplicating overlap.context/VectorStoreContext.tsxowns store init/teardown (idempotent, abortable on unmount).Two migrations run on launch (
database/db.ts,database/vectorStoreMigration.ts,utils/embeddingModelMigration.ts):ALTER TABLEcolumns onto older DBs; non-transactional but idempotent-retry-safe (covered by__tests__/dbSchemaMigration.test.ts).vectorstable is dropped, and changing the embedding model wipes previously imported sources (their embeddings are model-specific):sources+chatSourcesare cleared and the user re-imports. This is deliberate — old embeddings can't be reused across models — and is the one user-visible breaking behavior in this PR. The wipe fires only on a genuine model change: a missing model key (e.g. cleared AsyncStorage on a populated store) adopts the current model without deleting, so a lost key never silently destroys the user's imported sources.The app ↔ library boundary
The app delegates the "boring" layer (embeddings runtime, vector store, splitters) to
react-native-ragand owns only deliberate extensions:query:/document:) — required by LFM 2.5.documentId:chunkIndex) — make neighbor expansion pure id arithmetic and let a future keyword index join without a mapping table.Retriever(bottom ofretrieval.ts) is a thin wrapper binding store + embeddings into a singleretrieve(query, options)call.Key decisions / trade-offs
constants/retrieval.tswith per-value rationale (where the number comes from, what moving it does).[n]markers;pickCitationsByAnswerinfers which sources were used from answer↔passage term overlap. A heuristic, not ground truth.Known limitations
hydrateChunksByIdsreads op-sqlite's internal schema. Raw SQL against the library'svectorstable (a get-by-ids the public API lacks) — fast, but coupled to library internals; revisit on op-sqlite bumps.How to test locally
yarn jest(493 tests) andyarn lint.Notes for the reviewer
store/sourceStore.ts,hooks/useAttachment.ts) → retrieval (utils/retrieval.ts,utils/contextUtils.ts) → citations (utils/messageSources.ts,utils/citationHighlight.ts) → chat wiring.feat/rag-hybridis stacked on this branch and should merge after it.